Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 9ec2a88817819d433c5d68dbec0ca070ca989b91


Parents : c6768bc
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-22T19:28:50-05:00

feat(ui): implement clampFloatingToViewport utility for dynamic dropdown positioning across multiple components

Changes
Diff

diff --git a/meshchatx/src/frontend/components/DropDownMenu.vue b/meshchatx/src/frontend/components/DropDownMenu.vue
index 9671446e..c930f627 100644
--- a/meshchatx/src/frontend/components/DropDownMenu.vue
+++ b/meshchatx/src/frontend/components/DropDownMenu.vue
@@ -21,11 +21,9 @@
>
<div
v-if="isShowingMenu && dropdownPosition"
- class="overflow-hidden fixed z-[200] w-56 rounded-md bg-white dark:bg-zinc-800 shadow-lg border border-gray-200 dark:border-zinc-700 focus:outline-none"
- :style="{
- left: dropdownPosition.x + 'px',
- top: dropdownPosition.y + 'px',
- }"
+ ref="dropdownPanel"
+ class="overflow-x-hidden fixed z-[200] w-56 rounded-md bg-white dark:bg-zinc-800 shadow-lg border border-gray-200 dark:border-zinc-700 focus:outline-none"
+ :style="dropdownPanelStyle"
@click.stop="hideMenu"
>
<slot name="items" />
@@ -36,6 +34,8 @@
</template>
<script>
+import { clampFloatingToViewport } from "../js/clampFloatingToViewport.js";
+
export default {
name: "DropDownMenu",
data() {
@@ -44,6 +44,24 @@ export default {
dropdownPosition: null,
};
},
+ computed: {
+ dropdownPanelStyle() {
+ if (!this.dropdownPosition) {
+ return {};
+ }
+ const style = {
+ left: `${this.dropdownPosition.x}px`,
+ top: `${this.dropdownPosition.y}px`,
+ };
+ if (this.dropdownPosition.maxHeight != null) {
+ style.maxHeight = `${this.dropdownPosition.maxHeight}px`;
+ style.overflowY = "auto";
+ } else {
+ style.overflow = "hidden";
+ }
+ return style;
+ },
+ },
methods: {
toggleMenu() {
if (this.isShowingMenu) {
@@ -71,24 +89,22 @@ export default {
if (!button) return;
const buttonRect = button.getBoundingClientRect();
- const estimatedHeight = 200;
- const spaceBelow = window.innerHeight - buttonRect.bottom;
- const spaceAbove = buttonRect.top;
-
- let x = buttonRect.right - 224;
- if (x < 8) x = 8;
- if (x + 224 > window.innerWidth) x = window.innerWidth - 224 - 8;
+ const menuWidth = 224;
+ let x = buttonRect.right - menuWidth;
+ x = Math.min(Math.max(8, x), window.innerWidth - menuWidth - 8);
- let y;
- if (spaceBelow >= estimatedHeight || spaceBelow >= spaceAbove) {
- y = buttonRect.bottom + 4;
- } else {
- y = buttonRect.top - estimatedHeight - 4;
- }
- if (y < 8) y = 8;
- if (y + estimatedHeight > window.innerHeight - 8) y = window.innerHeight - estimatedHeight - 8;
+ const spaceBelow = window.innerHeight - buttonRect.bottom - 4;
+ const spaceAbove = buttonRect.top - 8;
+ let y = spaceBelow >= spaceAbove ? buttonRect.bottom + 4 : Math.max(8, buttonRect.top - 200 - 4);
- this.dropdownPosition = { x, y };
+ this.dropdownPosition = { x, y, maxHeight: null };
+ this.$nextTick(() => {
+ const panel = this.$refs.dropdownPanel;
+ if (!panel) return;
+ const rect = panel.getBoundingClientRect();
+ const { left, top, maxHeight } = clampFloatingToViewport(rect.left, rect.top, rect.width, rect.height);
+ this.dropdownPosition = { x: left, y: top, maxHeight };
+ });
});
},
},

diff --git a/meshchatx/src/frontend/components/LanguageSelector.vue b/meshchatx/src/frontend/components/LanguageSelector.vue
index c15b9a8a..a2e72fff 100644
--- a/meshchatx/src/frontend/components/LanguageSelector.vue
+++ b/meshchatx/src/frontend/components/LanguageSelector.vue
@@ -14,8 +14,9 @@
<Teleport to="body">
<div
v-if="isDropdownOpen"
+ ref="languageDropdown"
v-click-outside="closeDropdown"
- class="fixed w-48 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] overflow-hidden"
+ class="fixed w-48 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] overflow-x-hidden"
:style="dropdownStyle"
>
<div class="p-2">
@@ -42,6 +43,7 @@
<script>
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
+import { clampFloatingToViewport } from "../js/clampFloatingToViewport.js";
const localeModules = import.meta.glob("../locales/*.json", { eager: true });
const discoveredLanguages = Object.entries(localeModules)
@@ -80,6 +82,7 @@ export default {
return {
isDropdownOpen: false,
dropdownPosition: { top: 0, left: 0 },
+ dropdownMaxHeight: null,
};
},
computed: {
@@ -90,10 +93,17 @@ export default {
return discoveredLanguages;
},
dropdownStyle() {
- return {
+ const style = {
top: `${this.dropdownPosition.top}px`,
left: `${this.dropdownPosition.left}px`,
};
+ if (this.dropdownMaxHeight != null) {
+ style.maxHeight = `${this.dropdownMaxHeight}px`;
+ style.overflowY = "auto";
+ } else {
+ style.overflow = "hidden";
+ }
+ return style;
},
},
methods: {
@@ -106,10 +116,19 @@ export default {
updateDropdownPosition(event) {
const button = event.currentTarget;
const rect = button.getBoundingClientRect();
+ this.dropdownMaxHeight = null;
this.dropdownPosition = {
top: rect.bottom + 8,
- left: Math.max(8, rect.right - 192), // 192px is w-48
+ left: Math.max(8, rect.right - 192),
};
+ this.$nextTick(() => {
+ const panel = this.$refs.languageDropdown;
+ if (!panel) return;
+ const pr = panel.getBoundingClientRect();
+ const { left, top, maxHeight } = clampFloatingToViewport(pr.left, pr.top, pr.width, pr.height);
+ this.dropdownPosition = { left, top };
+ this.dropdownMaxHeight = maxHeight;
+ });
},
closeDropdown() {
this.isDropdownOpen = false;

diff --git a/meshchatx/src/frontend/components/NotificationBell.vue b/meshchatx/src/frontend/components/NotificationBell.vue
index c6fbacf1..6ce111c2 100644
--- a/meshchatx/src/frontend/components/NotificationBell.vue
+++ b/meshchatx/src/frontend/components/NotificationBell.vue
@@ -19,8 +19,9 @@
<Teleport to="body">
<div
v-if="isDropdownOpen"
+ ref="notificationDropdown"
v-click-outside="closeDropdown"
- class="fixed w-80 sm:w-96 md:max-lg:w-80 lg:w-96 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] max-h-[500px] overflow-hidden flex flex-col"
+ class="fixed w-80 sm:w-96 md:max-lg:w-80 lg:w-96 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] max-h-[min(500px,calc(100vh-2rem))] overflow-hidden flex flex-col"
:style="dropdownStyle"
>
<div class="p-4 border-b border-gray-200 dark:border-zinc-800">
@@ -147,6 +148,7 @@ import MaterialDesignIcon from "./MaterialDesignIcon.vue";
import Utils from "../js/Utils";
import WebSocketConnection from "../js/WebSocketConnection";
import GlobalState from "../js/GlobalState";
+import { clampFloatingToViewport } from "../js/clampFloatingToViewport.js";
export default {
name: "NotificationBell",
@@ -177,15 +179,20 @@ export default {
unreadCount: 0,
reloadInterval: null,
dropdownPosition: { top: 0, left: 0 },
+ dropdownMaxHeight: null,
showHistory: false,
};
},
computed: {
dropdownStyle() {
- return {
+ const style = {
top: `${this.dropdownPosition.top}px`,
left: `${this.dropdownPosition.left}px`,
};
+ if (this.dropdownMaxHeight != null) {
+ style.maxHeight = `${this.dropdownMaxHeight}px`;
+ }
+ return style;
},
},
beforeUnmount() {
@@ -224,18 +231,30 @@ export default {
if (hadNotifications) {
await this.loadNotifications({ updateList: false });
}
+ await this.$nextTick();
+ this.clampNotificationDropdown();
}
},
updateDropdownPosition(event) {
const button = event.currentTarget;
const rect = button.getBoundingClientRect();
const isMobile = window.innerWidth < 640;
- const dropdownWidth = isMobile ? 320 : 384; // 80 (320px) or 96 (384px)
+ const dropdownWidth = isMobile ? 320 : 384;
+ this.dropdownMaxHeight = null;
this.dropdownPosition = {
top: rect.bottom + 8,
left: Math.max(16, rect.right - dropdownWidth),
};
+ this.$nextTick(() => this.clampNotificationDropdown());
+ },
+ clampNotificationDropdown() {
+ const panel = this.$refs.notificationDropdown;
+ if (!panel || !this.isDropdownOpen) return;
+ const pr = panel.getBoundingClientRect();
+ const { left, top, maxHeight } = clampFloatingToViewport(pr.left, pr.top, pr.width, pr.height);
+ this.dropdownPosition = { top, left };
+ this.dropdownMaxHeight = maxHeight;
},
closeDropdown() {
this.isDropdownOpen = false;
@@ -251,6 +270,10 @@ export default {
await this.loadNotifications({ updateList: false });
}
}
+ if (this.isDropdownOpen) {
+ await this.$nextTick();
+ this.clampNotificationDropdown();
+ }
},
async loadNotifications(options = {}) {
const updateList = options.updateList !== false;

diff --git a/meshchatx/src/frontend/components/contextmenu/ContextMenuPanel.vue b/meshchatx/src/frontend/components/contextmenu/ContextMenuPanel.vue
index ada2d59f..95655f81 100644
--- a/meshchatx/src/frontend/components/contextmenu/ContextMenuPanel.vue
+++ b/meshchatx/src/frontend/components/contextmenu/ContextMenuPanel.vue
@@ -3,9 +3,10 @@
<template>
<div
v-if="show"
+ ref="panel"
class="context-menu-panel"
:class="panelClass"
- :style="{ top: y + 'px', left: x + 'px' }"
+ :style="panelStyle"
v-bind="$attrs"
>
<slot name="header" />
@@ -14,6 +15,8 @@
</template>
<script>
+import { clampFloatingToViewport } from "../../js/clampFloatingToViewport.js";
+
export default {
name: "ContextMenuPanel",
inheritAttrs: false,
@@ -35,5 +38,90 @@ export default {
default: "",
},
},
+ data() {
+ return {
+ adjustedLeft: 0,
+ adjustedTop: 0,
+ panelMaxHeight: null,
+ repositionRaf: null,
+ };
+ },
+ computed: {
+ panelStyle() {
+ const style = {
+ top: `${this.adjustedTop}px`,
+ left: `${this.adjustedLeft}px`,
+ };
+ if (this.panelMaxHeight != null) {
+ style.maxHeight = `${this.panelMaxHeight}px`;
+ style.overflowY = "auto";
+ }
+ return style;
+ },
+ },
+ watch: {
+ show: {
+ immediate: true,
+ handler(visible) {
+ this.cancelReposition();
+ if (!visible) {
+ this.panelMaxHeight = null;
+ return;
+ }
+ this.adjustedLeft = this.x;
+ this.adjustedTop = this.y;
+ this.panelMaxHeight = null;
+ this.scheduleReposition();
+ },
+ },
+ x() {
+ if (this.show) {
+ this.scheduleReposition();
+ }
+ },
+ y() {
+ if (this.show) {
+ this.scheduleReposition();
+ }
+ },
+ },
+ mounted() {
+ window.addEventListener("resize", this.onWindowResize);
+ },
+ beforeUnmount() {
+ window.removeEventListener("resize", this.onWindowResize);
+ this.cancelReposition();
+ },
+ methods: {
+ onWindowResize() {
+ if (this.show) {
+ this.repositionToViewport();
+ }
+ },
+ cancelReposition() {
+ if (this.repositionRaf != null) {
+ cancelAnimationFrame(this.repositionRaf);
+ this.repositionRaf = null;
+ }
+ },
+ scheduleReposition() {
+ this.cancelReposition();
+ this.repositionRaf = requestAnimationFrame(() => {
+ this.repositionRaf = null;
+ this.$nextTick(() => this.repositionToViewport());
+ });
+ },
+ repositionToViewport() {
+ const el = this.$refs.panel;
+ if (!el || !this.show) {
+ return;
+ }
+ const rect = el.getBoundingClientRect();
+ const { left, top, maxHeight } = clampFloatingToViewport(this.x, this.y, rect.width, rect.height);
+ this.adjustedLeft = left;
+ this.adjustedTop = top;
+ this.panelMaxHeight = maxHeight;
+ },
+ },
};
</script>

diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 8ef520a1..77e80ff5 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -1616,6 +1616,7 @@
<script>
import Utils from "../../js/Utils";
+import { clampFloatingToViewport } from "../../js/clampFloatingToViewport.js";
import { isNearBottom, scrollContainerToBottom, shouldLoadPreviousMessages } from "./conversationScroll.js";
import {
isTelemetryOnly as isTelemetryOnlyMessage,
@@ -3332,10 +3333,13 @@ export default {
const mv = me.touches ? me.touches[0] : me;
const dx = mv.clientX - this.reactionDragState.startX;
const dy = mv.clientY - this.reactionDragState.startY;
- this.reactionPickerPos = {
- x: this.reactionDragState.originX + dx,
- y: this.reactionDragState.originY + dy,
- };
+ const panelEl = this.$refs.reactionPickerPanel;
+ if (!panelEl) return;
+ const pr = panelEl.getBoundingClientRect();
+ const nx = this.reactionDragState.originX + dx;
+ const ny = this.reactionDragState.originY + dy;
+ const { left, top } = clampFloatingToViewport(nx, ny, pr.width, pr.height);
+ this.reactionPickerPos = { x: left, y: top };
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);

diff --git a/meshchatx/src/frontend/js/clampFloatingToViewport.js b/meshchatx/src/frontend/js/clampFloatingToViewport.js
new file mode 100644
index 00000000..44b93665
--- /dev/null
+++ b/meshchatx/src/frontend/js/clampFloatingToViewport.js
@@ -0,0 +1,38 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Clamp top-left coordinates for a fixed-position panel so it stays on-screen.
+ *
+ * @param {number} preferredLeft
+ * @param {number} preferredTop
+ * @param {number} width
+ * @param {number} height
+ * @param {{ margin?: number }} [options]
+ * @returns {{ left: number, top: number, maxHeight: number | null }}
+ */
+export function clampFloatingToViewport(preferredLeft, preferredTop, width, height, options = {}) {
+ const margin = options.margin ?? 8;
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+ const maxW = Math.max(0, vw - 2 * margin);
+ const maxH = Math.max(0, vh - 2 * margin);
+
+ let left = preferredLeft;
+ let top = preferredTop;
+
+ if (width <= maxW) {
+ left = Math.min(Math.max(margin, left), vw - width - margin);
+ } else {
+ left = margin;
+ }
+
+ let maxHeight = null;
+ if (height <= maxH) {
+ top = Math.min(Math.max(margin, top), vh - height - margin);
+ } else {
+ top = margin;
+ maxHeight = maxH;
+ }
+
+ return { left, top, maxHeight };
+}

diff --git a/tests/frontend/clampFloatingToViewport.test.js b/tests/frontend/clampFloatingToViewport.test.js
new file mode 100644
index 00000000..530c1026
--- /dev/null
+++ b/tests/frontend/clampFloatingToViewport.test.js
@@ -0,0 +1,90 @@
+import { readFileSync } from "fs";
+import { join } from "path";
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { clampFloatingToViewport } from "../../meshchatx/src/frontend/js/clampFloatingToViewport.js";
+
+function readSource(relativePath) {
+ return readFileSync(join(process.cwd(), relativePath), "utf8");
+}
+
+describe("clampFloatingToViewport", () => {
+ let innerWidth;
+ let innerHeight;
+
+ beforeEach(() => {
+ innerWidth = 800;
+ innerHeight = 600;
+ Object.defineProperty(window, "innerWidth", {
+ configurable: true,
+ get: () => innerWidth,
+ });
+ Object.defineProperty(window, "innerHeight", {
+ configurable: true,
+ get: () => innerHeight,
+ });
+ });
+
+ it("returns preferred position when panel fits inside the viewport", () => {
+ const { left, top, maxHeight } = clampFloatingToViewport(100, 120, 200, 150);
+ expect(left).toBe(100);
+ expect(top).toBe(120);
+ expect(maxHeight).toBeNull();
+ });
+
+ it("shifts left when the panel would extend past the right edge", () => {
+ const w = 200;
+ const { left, top, maxHeight } = clampFloatingToViewport(700, 50, w, 100);
+ expect(left).toBe(800 - w - 8);
+ expect(top).toBe(50);
+ expect(maxHeight).toBeNull();
+ });
+
+ it("shifts up when the panel would extend past the bottom edge", () => {
+ const h = 200;
+ const { left, top, maxHeight } = clampFloatingToViewport(40, 520, 160, h);
+ expect(left).toBe(40);
+ expect(top).toBe(600 - h - 8);
+ expect(maxHeight).toBeNull();
+ });
+
+ it("uses custom margin", () => {
+ const w = 100;
+ const { left } = clampFloatingToViewport(790, 10, w, 50, { margin: 16 });
+ expect(left).toBe(800 - w - 16);
+ });
+
+ it("returns maxHeight when content is taller than the viewport allows", () => {
+ innerHeight = 400;
+ Object.defineProperty(window, "innerHeight", {
+ configurable: true,
+ get: () => innerHeight,
+ });
+ const { left, top, maxHeight } = clampFloatingToViewport(10, 10, 200, 900);
+ expect(left).toBe(10);
+ expect(top).toBe(8);
+ expect(maxHeight).toBe(400 - 16);
+ });
+
+ it("pins wide panels to the left margin when wider than usable width", () => {
+ innerWidth = 300;
+ Object.defineProperty(window, "innerWidth", {
+ configurable: true,
+ get: () => innerWidth,
+ });
+ const { left } = clampFloatingToViewport(50, 10, 500, 40);
+ expect(left).toBe(8);
+ });
+});
+
+describe("clampFloatingToViewport wiring", () => {
+ it.each([
+ ["DropDownMenu.vue", "meshchatx/src/frontend/components/DropDownMenu.vue", 'ref="dropdownPanel"'],
+ ["LanguageSelector.vue", "meshchatx/src/frontend/components/LanguageSelector.vue", 'ref="languageDropdown"'],
+ ["NotificationBell.vue", "meshchatx/src/frontend/components/NotificationBell.vue", 'ref="notificationDropdown"'],
+ ["ConversationViewer.vue", "meshchatx/src/frontend/components/messages/ConversationViewer.vue", "onReactionPickerDragStart"],
+ ])("%s imports the helper and clamps floating UI", (_, relativePath, anchor) => {
+ const src = readSource(relativePath);
+ expect(src).toContain("clampFloatingToViewport");
+ expect(src).toContain(anchor);
+ });
+});

diff --git a/tests/frontend/contextMenuStyles.test.js b/tests/frontend/contextMenuStyles.test.js
index 1cf0c9d0..7980959f 100644
--- a/tests/frontend/contextMenuStyles.test.js
+++ b/tests/frontend/contextMenuStyles.test.js
@@ -27,6 +27,14 @@ describe("context menu styling", () => {
expect(item).toContain("context-item");
});
+ it("ContextMenuPanel clamps position to the viewport via clampFloatingToViewport", () => {
+ const panel = readProjectFile("meshchatx/src/frontend/components/contextmenu/ContextMenuPanel.vue");
+ expect(panel).toContain("clampFloatingToViewport");
+ expect(panel).toContain('ref="panel"');
+ expect(panel).toContain("repositionToViewport");
+ expect(panel).toContain("resize");
+ });
+
it("uses ContextMenuPanel and ContextMenuItem on all right-click context menus", () => {
const files = [
"meshchatx/src/frontend/components/contacts/ContactsPage.vue",


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────